SPB Git

Repository Public

page.tsx 12.7 KB 364 lines tsx

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /rapports/[date] — une édition du rapport quotidien : contenu rédigé par3// Claude (édito, faits saillants, analyse, vigilance) + tableau de bord4// chiffré rendu côté serveur depuis le snapshot API·Ka figé au moment de la5// génération (data/rapports/YYYY-MM-DD.json). Le PDF est l'impression de6// CETTE page par Chromium headless (@media print dans globals.css) — une7// seule source de rendu, aucun template dupliqué.8import type { Metadata } from "next";9import Link from "next/link";10import { notFound } from "next/navigation";11import {12  getRapport,13  hasPdf,14  fmtDateLongue,15  fmtNombre,16  METEO_INFO,17  type ServiceSnap,18} from "@/lib/rapports";1920export const dynamic = "force-dynamic";2122type Props = { params: Promise<{ date: string }> };2324export async function generateMetadata({ params }: Props): Promise<Metadata> {25  const { date } = await params;26  const rapport = await getRapport(date);27  if (!rapport) return { title: "Rapport introuvable" };28  return {29    title: `${rapport.contenu.titre} — rapport du ${fmtDateLongue(date)}`,30    description:31      rapport.contenu.sous_titre ??32      `Rapport quotidien du Groupe KA — ${fmtDateLongue(date)}.`,33  };34}3536/** Rend un paragraphe en interprétant uniquement **gras** (aucun autre markdown). */37function Para({ texte, className }: { texte: string; className?: string }) {38  const morceaux = texte.split(/\*\*(.+?)\*\*/g);39  return (40    <p className={className}>41      {morceaux.map((m, i) =>42        i % 2 === 1 ? <strong key={i}>{m}</strong> : m,43      )}44    </p>45  );46}4748function Sparkline({ serie }: { serie: { d: string; n: number }[] }) {49  if (serie.length < 2) return null;50  const w = 120;51  const h = 34;52  const vals = serie.map((p) => p.n);53  const min = Math.min(...vals);54  const max = Math.max(...vals);55  const span = max - min || 1;56  const pts = serie57    .map(58      (p, i) =>59        `${((i / (serie.length - 1)) * (w - 4) + 2).toFixed(1)},${(h - 4 - ((p.n - min) / span) * (h - 8)).toFixed(1)}`,60    )61    .join(" ");62  return (63    <svg64      viewBox={`0 0 ${w} ${h}`}65      className="h-[34px] w-[120px]"66      aria-hidden="true"67    >68      <polyline69        points={pts}70        fill="none"71        stroke="var(--green)"72        strokeWidth="2"73        strokeLinejoin="round"74        strokeLinecap="round"75      />76    </svg>77  );78}7980function Delta({ pct }: { pct: number | null }) {81  if (pct === null) return <span className="klabel">—</span>;82  const up = pct >= 0;83  return (84    <span85      className={`gk-mono text-[12px] font-bold ${up ? "text-green" : "text-[var(--danger,#b3423a)]"}`}86    >87      {up ? "▲" : "▼"} {Math.abs(pct).toLocaleString("fr-CA")} %88    </span>89  );90}9192function LigneService({ s, maxJour }: { s: ServiceSnap; maxJour: number }) {93  const largeur =94    s.jour !== null && maxJour > 0 ? Math.max((s.jour / maxJour) * 100, 1.5) : 0;95  return (96    <li className="grid grid-cols-[110px_1fr_auto] items-center gap-x-3 gap-y-1 border-b border-dashed border-[rgba(20,24,20,0.18)] py-3 last:border-0 sm:grid-cols-[130px_1fr_130px_150px]">97      <p className="gk-display text-[14px] font-bold">{s.nom}</p>98      <div>99        <div className="h-[14px] overflow-hidden rounded-[4px] border border-ink/40 bg-[rgba(20,24,20,0.05)]">100          <div101            className="h-full bg-lime"102            style={{ width: `${largeur}%` }}103            aria-hidden="true"104          />105        </div>106      </div>107      <p className="hidden justify-self-end sm:block">108        <Sparkline serie={s.serie_14j} />109      </p>110      <p className="col-span-2 text-right sm:col-span-1">111        <span className="gk-mono text-[13px] font-bold">112          {fmtNombre(s.jour)}113        </span>{" "}114        <Delta pct={s.delta_pct} />115      </p>116    </li>117  );118}119120export default async function RapportPage({ params }: Props) {121  const { date } = await params;122  const rapport = await getRapport(date);123  if (!rapport) notFound();124  const { contenu, snapshot } = rapport;125  const pdf = await hasPdf(date);126  const meteo = contenu.meteo ? METEO_INFO[contenu.meteo.etat] : null;127  const maxJour = Math.max(...snapshot.services.map((s) => s.jour ?? 0), 0);128  const genereA = new Intl.DateTimeFormat("fr-CA", {129    hour: "2-digit",130    minute: "2-digit",131    timeZone: "America/Toronto",132  }).format(new Date(rapport.genere_le));133134  return (135    <main className="mx-auto max-w-4xl px-4 py-12 sm:px-6">136      {/* fil d'Ariane + actions */}137      <div className="no-print flex flex-wrap items-center justify-between gap-3">138        <Link139          href="/rapports"140          className="gk-mono text-[12px] font-bold tracking-[0.08em] uppercase underline-offset-4 hover:underline"141        >142          ← Tous les rapports143        </Link>144        {pdf && (145          <a146            href={`/api/rapports/${date}/pdf`}147            className="btn btn-primary !min-h-[38px] !px-4 !text-[13px]"148          >149            Télécharger le PDF ↓150          </a>151        )}152      </div>153154      {/* manchette */}155      <header className="mt-8">156        <p className="kicker">Rapport quotidien · Groupe KA × Claude</p>157        <p className="gk-mono mt-3 text-[12.5px] font-bold tracking-[0.08em] text-ink-3 uppercase">158          {fmtDateLongue(date)} · édition de {genereA}159        </p>160        <h1 className="gk-display mt-3 text-[clamp(26px,4.5vw,42px)] leading-[1.04] font-bold tracking-[-0.035em]">161          {contenu.titre}162        </h1>163        {contenu.sous_titre && (164          <p className="mt-4 max-w-2xl text-[15.5px] leading-relaxed text-ink-2">165            {contenu.sous_titre}166          </p>167        )}168        {meteo && contenu.meteo && (169          <p className="gk-card mt-5 inline-flex flex-wrap items-center gap-2 px-4 py-2.5 text-[13.5px]">170            <span aria-hidden="true">{meteo.icone}</span>171            <strong className="gk-display">{meteo.label}</strong>172            <span className="text-ink-2">— {contenu.meteo.resume}</span>173          </p>174        )}175      </header>176177      {/* chiffre du jour + repères */}178      <section className="mt-8 grid gap-4 sm:grid-cols-3">179        {contenu.chiffre_du_jour && (180          <div className="gk-card bg-lime p-5 sm:col-span-1">181            <p className="klabel !text-ink/70">Le chiffre du jour</p>182            <p className="gk-display mt-1 text-[34px] leading-none font-bold tracking-[-0.03em]">183              {contenu.chiffre_du_jour.valeur}184            </p>185            <p className="gk-display mt-2 text-[13.5px] font-bold">186              {contenu.chiffre_du_jour.label}187            </p>188            {contenu.chiffre_du_jour.contexte && (189              <p className="mt-2 text-[12.5px] leading-snug text-ink/80">190                {contenu.chiffre_du_jour.contexte}191              </p>192            )}193          </div>194        )}195        <div className="grid grid-cols-2 gap-4 sm:col-span-2">196          {[197            {198              label: "Enregistrements collectés",199              valeur: fmtNombre(snapshot.totaux.records_jour),200              note: `collecte du ${snapshot.date_donnees}`,201            },202            {203              label: "Total en base",204              valeur: fmtNombre(snapshot.totaux.records_total),205              note: "9 plateformes de données",206            },207            {208              label: "Connecteurs en santé",209              valeur: snapshot.connecteurs210                ? `${snapshot.connecteurs.ok}`211                : "—",212              note: snapshot.connecteurs213                ? `${snapshot.connecteurs.degraded} dégradé(s) · ${snapshot.connecteurs.broken} brisé(s)`214                : "santé indisponible",215            },216            {217              label: "API·Ka (7 jours)",218              valeur:219                snapshot.api_7j.appels !== null220                  ? `${fmtNombre(snapshot.api_7j.appels)} appels`221                  : "—",222              note:223                snapshot.api_7j.latence_ms !== null224                  ? `${Math.round(snapshot.api_7j.latence_ms)} ms de latence moyenne`225                  : "",226            },227          ].map((k) => (228            <div key={k.label} className="gk-card p-4">229              <p className="klabel">{k.label}</p>230              <p className="gk-display mt-1 text-[22px] leading-none font-bold">231                {k.valeur}232              </p>233              {k.note && (234                <p className="gk-mono mt-2 text-[10.5px] text-ink-3">235                  {k.note}236                </p>237              )}238            </div>239          ))}240        </div>241      </section>242243      {/* éditorial */}244      <section className="mt-10">245        <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">246          L&apos;éditorial de la nuit247        </h2>248        <div className="mt-4 space-y-4 border-l-2 border-lime pl-5">249          {contenu.edito.map((p, i) => (250            <Para251              key={i}252              texte={p}253              className="max-w-3xl text-[15px] leading-[1.75] text-ink-2"254            />255          ))}256        </div>257      </section>258259      {/* la nuit en chiffres — barres + sparklines par plateforme */}260      <section className="mt-10">261        <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">262          La nuit en chiffres263        </h2>264        <p className="gk-mono mt-1 text-[11px] text-ink-3">265          Enregistrements par plateforme (collecte du {snapshot.date_donnees})266          · tendance 14 jours · variation vs la veille267        </p>268        <ul className="gk-card mt-4 px-5 py-2">269          {[...snapshot.services]270            .sort((a, b) => (b.jour ?? 0) - (a.jour ?? 0))271            .map((s) => (272              <LigneService key={s.service} s={s} maxJour={maxJour} />273            ))}274        </ul>275      </section>276277      {/* faits saillants */}278      <section className="mt-10">279        <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">280          Faits saillants281        </h2>282        <ul className="mt-4 grid gap-4 sm:grid-cols-2">283          {contenu.faits_saillants.map((f) => (284            <li key={f.plateforme} className="gk-card p-4">285              <p className="gk-mono text-[11px] font-bold tracking-[0.1em] text-green uppercase">286                {f.plateforme}287              </p>288              <Para289                texte={f.texte}290                className="mt-2 text-[13.5px] leading-relaxed text-ink-2"291              />292            </li>293          ))}294        </ul>295      </section>296297      {/* analyse */}298      {contenu.analyse.map((sec) => (299        <section key={sec.titre} className="mt-10">300          <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">301            {sec.titre}302          </h2>303          <div className="mt-4 space-y-4">304            {sec.contenu.map((p, i) => (305              <Para306                key={i}307                texte={p}308                className="max-w-3xl text-[14.5px] leading-[1.75] text-ink-2"309              />310            ))}311          </div>312        </section>313      ))}314315      {/* vigilance */}316      {(contenu.vigilance?.length ?? 0) > 0 && (317        <section className="mt-10">318          <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">319            À surveiller320          </h2>321          <ul className="gk-card mt-4 space-y-3 border-[var(--amber,#e8a33d)] p-5">322            {contenu.vigilance!.map((v, i) => (323              <li key={i} className="flex gap-3 text-[13.5px] text-ink-2">324                <span aria-hidden="true">⚠️</span>325                <Para texte={v} />326              </li>327            ))}328          </ul>329        </section>330      )}331332      {/* mot de la fin */}333      {contenu.mot_de_la_fin && (334        <blockquote className="gk-display mt-12 border-l-4 border-ink pl-5 text-[19px] leading-snug font-bold tracking-[-0.02em]">335          « {contenu.mot_de_la_fin} »336        </blockquote>337      )}338339      {/* méthode */}340      <p className="gk-mono mt-12 text-[11px] leading-relaxed text-ink-3">341        Méthode : rapport rédigé automatiquement le {fmtDateLongue(date)} à{" "}342        {genereA} par le modèle {rapport.modele} (Anthropic) à partir des343        données mesurées par API·Ka — collectes quotidiennes de 02:00 sur les 9344        plateformes, historique des runs, santé des connecteurs et345        statistiques d&apos;appels. Le tableau « la nuit en chiffres » est rendu346        directement depuis les données, sans passer par le modèle. Aucun347        chiffre inventé ni extrapolé. Le PDF est l&apos;impression fidèle de348        cette page.349      </p>350351      <div className="no-print mt-8 flex flex-wrap gap-3">352        <Link href="/rapports" className="btn btn-ghost">353          ← Tous les rapports354        </Link>355        {pdf && (356          <a href={`/api/rapports/${date}/pdf`} className="btn btn-primary">357            Télécharger le PDF ↓358          </a>359        )}360      </div>361    </main>362  );363}364
Files main
Menu

Theme